Skip to content

perf: single dispute-chat REQ and constant-time session resolution - #714

Merged
grunch merged 3 commits into
mainfrom
perf/dispute-chat-single-req
Sep 1, 2026
Merged

perf: single dispute-chat REQ and constant-time session resolution#714
grunch merged 3 commits into
mainfrom
perf/dispute-chat-single-req

Conversation

@grunch

@grunch grunch commented Sep 1, 2026

Copy link
Copy Markdown
Member

Summary

Item 4.5 (last of Phase 4) of the performance plan. Two dispute-chat wastes:

  1. Duplicate REQ: each DisputeChatNotifier opened its own kind-14 REQ — a duplicate of the one SubscriptionManager already maintains for SubscriptionType.disputeChat, whose stream nobody consumed.
  2. O(sessions) resolution with side effects: _getSessionForDispute scanned every session and ref.read(orderNotifierProvider(id)) per candidate — instantiating an OrderNotifier (DB sync + storage watcher + book listener) for any session that didn't have one — on every incoming event, send, and read-status check.

Changes

  • The notifier consumes the manager's shared disputeChat broadcast (the existing K_sign pre-filter keeps per-dispute isolation); one REQ serves all disputes, bounded by the manager's shared persisted cursor.
  • _getSessionForDispute resolves via the persisted session.disputeId first (constant time, side-effect free — pinned by a test whose orderNotifierProvider override throws); the order-state scan stays only as a fallback for sessions persisted before disputeId existed.
  • Subscription.cancel tolerates teardown ordering (container disposal with open REQs no longer throws from onCancel) — surfaced by the reload test once the real manager participates in its container.

Test plan

  • New dispute_chat_single_req_test.dart (RED on main): an envelope pushed through the shared manager stream reaches the notifier, with orderNotifierProvider overridden to throw
  • Disputes + subscriptions suites — green except the known pre-existing dispute_chat_duplicate_envelope_test (fixed by test: fix the duplicate-envelope race pin for cross-isolate unwrapping #710, not in this base)
  • Full suite halves — features: only that same known failure; rest all green
  • flutter analyze — no new issues
  • Manual: open two disputes — relay logs show one kind-14 dispute REQ total; messages route to the right dispute; admin messages while backgrounded still surface on resume

🤖 Generated with Claude Code

https://claude.ai/code/session_018fTxqxhpdL5siTgKZqwtur

The dispute chat notifier opened its own kind-14 REQ per dispute - a
duplicate of the one SubscriptionManager already maintains for
SubscriptionType.disputeChat, whose stream nobody consumed - and resolved
its session by scanning every session and instantiating an OrderNotifier
(DB sync, storage watcher, book listener) per candidate, on every
incoming event, send and read-status check.

- The notifier now consumes the manager's shared disputeChat stream
  (per-dispute filtering stays in the existing K_sign pre-filter), so one
  REQ serves all disputes and the manager's persisted shared cursor
  bounds the replay.
- _getSessionForDispute resolves through the persisted session.disputeId
  first (constant time, side-effect free); the order-state scan remains
  only as a fallback for sessions persisted before disputeId existed.
- Subscription.cancel tolerates teardown ordering: disposing the
  container while REQs are open no longer throws from onCancel.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T00:17:21.950813Z 8c7b09c PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 12 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: c56a1d5a-f965-4a6d-b63c-855ee318f01c

📥 Commits

Reviewing files that changed from the base of the PR and between 0381ff8 and 65ab00f.

📒 Files selected for processing (4)
  • lib/features/disputes/notifiers/dispute_chat_notifier.dart
  • lib/features/subscriptions/subscription_manager.dart
  • lib/services/lifecycle_manager.dart
  • test/features/disputes/dispute_chat_single_req_test.dart

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c7b09c9ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/features/disputes/notifiers/dispute_chat_notifier.dart Outdated

@Catrya Catrya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes

The session-resolution half is solid and I'd merge it as is. The single-REQ half
introduces a message-visibility regression that needs to be closed first.

What I verified

  • flutter analyze: clean.
  • disputes/, subscriptions/ and chat/ suites: green except
    dispute_chat_duplicate_envelope_test, which I confirmed also fails on the
    base commit
    ef3aad30 — the known pre-existing failure fixed by #710.
  • The new test is well designed: the throwing orderNotifierProvider override
    pins the "no OrderNotifier instantiation" guarantee properly.
  • session.disputeId is genuinely persisted (dispute_id in toJson) and set on
    both dispute-initiation paths (abstract_mostro_notifier.dart:564 and :653),
    so the fast path is correct and the fallback covers legacy sessions.
  • SubscriptionManager does rebuild the REQ when a dispute starts mid-session:
    _emitState() always assigns a fresh list, the listener fires, and
    _filterIdentity changes because it includes the adminSharedKey publics. No
    risk of a dispute ending up with no subscription at all.

Blocking: the backfill on opening a dispute chat is lost

DisputeChatNotifier is created lazily — only when DisputesList renders,
which lives behind the "Disputes" tab (chat_rooms_list.dart:94). That did not
matter before, because on creation the notifier opened its own REQ with
since = persisted cursor and the relay replayed whatever had been missed.

Now it only listens on a StreamController.broadcast(), and a broadcast
controller drops events while it has no listener
. So every kind-14 envelope
delivered on the shared REQ before the user enters the Disputes tab is discarded:
not displayed, not written to disk, and the cursor does not advance. That includes
the backlog the relay replays when the REQ is issued at app start.

I reproduced it by taking the PR's own test and pushing the envelope before
creating the notifier: Expected: length of <1>, Actual: [].

Contrast with P2P chat, where this hole does not exist: app_init_provider.dart:60-62
eagerly creates a ChatRoomNotifier per session with a peer, so the shared chat
stream always has a listener. Disputes have no equivalent.

To be fair on severity: the message is not lost forever. A background/foreground
cycle recovers it — the background service subscribes with the persisted filters,
decrypts and stores it (background_notification_service.dart:314), and
_switchToForeground invalidates the family so it reloads from disk. But in the
meantime the user opens their dispute and does not see the admin's latest message,
which does not happen today.

Suggested fix, either one:

  • Mirror the P2P pattern: create the notifier in app_init_provider for sessions
    with disputeId != null. Two lines, no new API — my preference.
  • Or have _subscribe() ask the manager to re-issue the disputeChat REQ once
    when it attaches, so the relay replays the backlog with the listener connected.
    Still a single REQ.

Plus a test pinning it: an envelope delivered on the shared stream before the
notifier exists must still end up visible.

Non-blocking nits

  • The try/catch in onCancel (subscription_manager.dart:471) swallows any
    error, not just the StateError from a disposed container. If unsubscribe()
    ever failed for another reason the relay CLOSE is silently skipped and the REQ
    lingers — exactly the waste this PR removes. It is logged, so it is tolerable,
    but narrowing the try to the ref.read would be more honest.
  • lifecycle_manager.dart:120-122 is now half stale: the notifier no longer
    "re-opens its relay subscription" because it never opens one.
  • Worth noting the two halves differ a lot in payoff: the duplicate REQ is one per
    relay and only while a dispute chat is open, whereas taking
    ref.read(orderNotifierProvider(...)) off the per-event path is the substantial
    win.

@grunch

grunch commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

`SubscriptionManager.disputeChat` is a broadcast stream, and a broadcast
stream drops events while nothing is listening. `DisputeChatNotifier` is
built lazily — only when the Disputes tab renders — so every envelope the
shared REQ delivered before that was discarded: not displayed, not persisted,
and the cursor never advanced. That includes the backlog the relay replays
when the REQ is first issued. Before this PR the notifier opened its own REQ
on creation, so the relay always backfilled it.

Adds `SubscriptionManager.refreshDisputeChatSubscription()`, asked for once
by the notifier when it attaches: it clears the applied filter key and
re-issues the REQ, so the relay replays from the persisted cursor with a
listener connected. Still a single REQ — re-issued once, when a consumer
shows up — and it also covers a dispute that starts mid-run, which eager
notifier creation at startup would miss.

Narrows the `onCancel` guard to the `ref.read`, per review: a failing
`unsubscribe()` means the relay CLOSE was skipped and the REQ lingers, which
is exactly the waste this PR removes, so it must surface rather than be
swallowed. Doing so exposed a latent NPE — dart_nostr only assigns
`subscriptionId` when it serializes the REQ onto a socket, so a request that
never reached a relay has none — now handled as "nothing to CLOSE".

Also refreshes the now-stale dispute comment in the foreground transition.
@grunch

grunch commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Gracias @Catrya — el bloqueante es real y lo reproduje antes de tocar nada. @chatgpt-codex-connector llegó al mismo P1 de forma independiente, así que lo trato como un único hallazgo. Arreglado en 65ab00f.

Bloqueante: se perdía el backfill al abrir el chat de disputa — CONFIRMADO y arreglado

Verificado punto por punto:

  • _disputeChatController es StreamController<NostrEvent>.broadcast() (subscription_manager.dart:42), y un broadcast descarta eventos mientras no tiene listener.
  • disputeChatNotifierProvider sólo se lee desde widgets de disputa (dispute_content, dispute_messages_list, dispute_message_input, dispute_message_bubble) más los invalidate de lifecycle y restore. DisputesList sólo se renderiza con currentTab == ChatTabType.disputes (chat_rooms_list.dart:94). Nada crea el notifier antes de que el usuario entre a la pestaña.
  • El contraste con P2P también es exacto: app_init_provider.dart:60-62 crea chatRoomsProvider por sesión con peer; para disputas no hay equivalente.
  • Y confirmo tu observación sobre el cursor: el manager sólo hace _disputeChatController.add(event) (:436), no avanza nada — el cursor lo avanza _onChatEvent en el notifier. Sin listener no hay persistencia ni avance de cursor.

Fix elegido: la opción (b), catch-up al enganchar. Preferí ésta sobre la creación eager en app_init_provider porque la eager deja un agujero: una disputa que empieza con la app corriendo no tiene notifier creado (el app_init ya corrió), el REQ se reconstruye al cambiar _filterIdentity, el relay replaya el backlog… y se vuelve a descartar por falta de listener. El catch-up cubre los dos casos con un solo mecanismo.

Nuevo SubscriptionManager.refreshDisputeChatSubscription(): limpia _appliedFilterKeys[disputeChat] vía unsubscribeByType (que ya existe justamente para forzar el próximo update) y re-emite. Sigue siendo un solo REQ, re-emitido una vez cuando aparece un consumidor. El notifier lo pide una sola vez por instancia (_requestedBackfill), porque _subscribe() puede correr de nuevo cuando llega el adminSharedKey.

Test, calcado de tu reproducción: an envelope delivered before the notifier exists is still recovered — el sobre se empuja al stream compartido antes de crear el notifier (y se descarta), el stub de refreshDisputeChatSubscription() modela el replay del relay, y se verifica que el mensaje termina visible. RED antes del fix.

Nit del try/catch en onCancel — arreglado, y destapó un bug

Tenías razón, y el catch amplio estaba escondiendo algo concreto: al estrechar el try sólo al ref.read, dispute_chat_reload_test empezó a fallar con Null check operator used on a null value en request.subscriptionId!.

Causa: dart_nostr asigna subscriptionId únicamente al serializar el REQ sobre un socket (request.dart:29-32, el campo es String?). Un request que nunca llegó a un relay no tiene id. Ese ! ya estaba en main; simplemente nunca se cancelaba un request no enviado hasta que refreshDisputeChatSubscription() empezó a cancelar más seguido. Ahora es un early return explícito ("no hay REQ que cerrar") y el fallo real de unsubscribe() vuelve a propagarse, como pedías.

Nit del comentario en lifecycle_manager.dart — corregido

Reescrito: ya no dice que el notifier "re-abre su suscripción de relay"; ahora explica que consume SubscriptionManager.disputeChat y que el rebuild re-engancha el listener y pide el catch-up.

Sobre el payoff

De acuerdo, y lo dejo anotado: sacar ref.read(orderNotifierProvider(...)) del camino por evento es la mitad sustancial. El REQ duplicado es menor. La mitad de resolución de sesión queda igual que la revisaste.


flutter analyze: limpio. Suite completa: 1292 passing, 0 failingdispute_chat_duplicate_envelope_test incluido, ya verde en esta base.

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

Testing

  • git diff --check
  • git status --short --branch — clean working tree after commit.
  • ⚠️ dart format lib/features/disputes/notifiers/dispute_chat_notifier.dart lib/features/subscriptions/subscription_manager.dart lib/services/lifecycle_manager.dart test/features/disputes/dispute_chat_single_req_test.dart — Dart is not installed in this environment.
  • ⚠️ dart run build_runner build -d — Dart is not installed in this environment.
  • ⚠️ flutter analyze — Flutter is not installed in this environment.
  • ⚠️ flutter test test/features/disputes/dispute_chat_single_req_test.dart — Flutter is not installed in this environment.

View task →

@grunch
grunch merged commit 753a6ef into main Sep 1, 2026
2 checks passed
@grunch
grunch deleted the perf/dispute-chat-single-req branch September 1, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants